Add FileSystemClient: System.IO-style async client over OPC UA file APIs#3760
Merged
Conversation
Implements Libraries/Opc.Ua.Client/FileSystem/ on top of the source-generated FileTypeClient, FileDirectoryTypeClient and TemporaryFileTransferTypeClient proxies in Opc.Ua.Core (Part 5 Annex C / Part 20 Section 4). Public surface mirrors System.IO: FileSystemClient (entry point), UaFileSystemInfo + UaFileInfo + UaDirectoryInfo, UaFileStream : System.IO.Stream (async + sync forwarders), UaPath. Separate TemporaryFileTransferClient + UaTemporaryWriteFile for Part 5 Section C.5 with single-terminal-call commit lifecycle. Path syntax is forward-slash only with namespace-aware QualifiedName segments. Path resolution caches resolved (parent, name)->child NodeIds in a small LRU. Type-aware enumeration via Session.TypeTree.IsTypeOf (subtype-aware by default). UaFileStream chunks Read/Write at FileSystemClientOptions.ChunkSize (clamped to MaxByteStringLength), tracks Length/Position locally, pushes SetPosition lazily, issues Close exactly once. DisposeAsync follows the recommended pattern from https://learn.microsoft.com/dotnet/standard/garbage-collection/implementing-disposeasync. Status codes (BadNoMatch, BadNotFound, BadBrowseNameDuplicated, BadUserAccessDenied, BadNotWritable, ...) are translated to FileNotFoundException / DirectoryNotFoundException / UnauthorizedAccessException / IOException at the public boundary. Tests: 111 unit tests under Tests/Opc.Ua.Client.Tests/FileSystem/ covering UaPath (28), PathCache (13), FileSystemErrors (13), UaFileStream (15), FileSystemClientOptions (6), TemporaryFileTransferClient (4), FileSystemClient path resolution (11), enumeration (6), metadata (3), and CRUD (12). All mock-based (Moq + a FileSystemSessionHarness fake address space). Docs/FileSystemClient.md describes the public API, path syntax, error mapping table, recursive-delete semantics, and the temporary-file-transfer flow. 0 errors, 0 warnings on Opc.Ua.Client and Opc.Ua.Client.Tests across all 6 TFMs (net472, net48, netstandard2.1, net8.0, net9.0, net10.0). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
New repo agent that scopes 'dotnet format' (whitespace + style + analyzers --severity info) to user-specified files, then chases remaining CA/IDE/RCS warnings to a 0-warning build. Includes a per-warning-code fix cookbook (CA1835, CA2007, CA2213, CA2215, CA1844, CA1861, CA1068, CA1859, CA1307/CA2249, RCS1007, RCS1135, RCS1166), the recommended DisposeAsync pattern from the .NET docs, and a list of anti-patterns observed when applying the same workflow manually. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
marcschier
commented
May 13, 2026
The test asserts disposedDelta == createdDelta on the process-wide Certificate.InstancesCreated / InstancesDisposed counters, but the owning fixture is [Parallelizable]. Any other test in the Opc.Ua.Core.Tests assembly (6729 tests) that allocates a Certificate during the snapshot window inflates createdDelta without a matching disposedDelta, intermittently failing the assertion on the Windows CI runner (the race is platform-sensitive — Ubuntu hits a different schedule and usually misses it). Marking the single test [NonParallelizable] gives it exclusive access to the counters for its ~267 ms run. The rest of the fixture stays [Parallelizable], so the wall-clock impact is negligible. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ReadCoreAsync: check ByteString.IsEmpty for EOF before allocating; copy via data.Span.CopyTo(buffer.AsSpan(...)) instead of materialising a temp byte[] via ToArray() + Buffer.BlockCopy. WriteCoreAsync: same optimization on the symmetric path — wrap the caller's slice via the zero-copy 'new ByteString(buffer.AsMemory(offset, length))' constructor. The encoder copies the bytes onto the wire during WriteAsync; the await guarantees the buffer is not reused before the request is fully serialised, so wrapping (without an explicit copy) is safe. All 111 FileSystem unit tests still pass on net10.0. Addresses feedback from @marcschier on OPCFoundation#3760 (comment) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
romanett
approved these changes
May 14, 2026
Removed conditional compilation for older platforms and the associated NoOp test.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Proposed changes
This PR adds a new
FileSystemClienttoLibraries/Opc.Ua.Client/FileSystem/that turns the source-generatedFileTypeClient/FileDirectoryTypeClient/TemporaryFileTransferTypeClientproxies (already emitted intoOpc.Ua.Corefrom the standard NodeSet) into an ergonomic,System.IO-style async-only client over OPC UA file systems (Part 5 Annex C, Part 20 §4).The public surface mirrors
System.IO:FileSystemClientSystem.IO.Directory+System.IO.FileUaFileSystemInfo(abstract) →UaFileInfo/UaDirectoryInfoFileSystemInfo→FileInfo/DirectoryInfoUaFileStream : System.IO.StreamFileStream(async + sync forwarders)UaPath(public static)System.IO.PathFileSystemClientOptionsTemporaryFileTransferClient+UaTemporaryWriteFileDesign highlights
'/'only; each segment parsed viaQualifiedName.Parseso"1:Reports/2024/data.csv"works. Canonical paths preserve the namespace index so siblings with the same.Namein different namespaces never collapse.TranslateBrowsePathsToNodeIdsper segment with a small LRU cache keyed by(parent NodeId, browse name). Cache hits are best-effort;BadNodeIdUnknownevicts and triggers exactly one re-resolution.Session.TypeTree.IsTypeOf(subtype-aware by default, soTrustListType,AddressSpaceFileType, etc. show up as files); enumeration filtersNodeClass=Object+ hierarchical references withIncludeSubtypes=true.UaFileStreamchunks Reads/Writes atFileSystemClientOptions.ChunkSize(clamped toFileType.MaxByteStringLengthwhen known); emptyByteString= EOF; zero-length read/write never hits the wire;Positiontracked locally with lazySetPositionAsync;DisposeAsyncissuesCloseexactly once. Sync members forward to async viaGetAwaiter().GetResult().BadNoMatch/BadNotFound/BadNodeIdUnknown→FileNotFoundException/DirectoryNotFoundException;BadBrowseNameDuplicated→IOException("already exists");BadUserAccessDenied/BadNotWritable→UnauthorizedAccessException; resource/state codes →IOException; everything else propagates asServiceResultException.DisposeAsyncpattern from https://learn.microsoft.com/dotnet/standard/garbage-collection/implementing-disposeasync applied throughout (DisposeAsync→DisposeAsyncCore→Dispose(bool)→SuppressFinalize);await usingconsumers use theawait using (x.ConfigureAwait(false))block-scope form so CA2007 is satisfied.DeleteAsync(recursive: false)on a directory enumerates first and throwsIOExceptionwhen non-empty;recursive: trueinvokes the server'sDeleteexactly once (per Part 20 §4.3 the server's primitive is recursive — the client never walks the tree itself).CreateFileAsyncalways passesrequestFileOpen: falseso server-allocated handles never leak through the create call. Leaf segments with a namespace prefix are rejected withArgumentExceptionsince the server picks the BrowseName namespace.UaTemporaryWriteFileowns the close lifecycle — exactly one terminal call (CommitAsync= CloseAndCommit, ORDisposeAsync= Close, server rollback). The wrappedStreamcannot accidentally close the server handle.Tests
111 unit tests under
Tests/Opc.Ua.Client.Tests/FileSystem/, all mock-based againstISessionClient/ISession(no live server required):UaPathTestsPathCacheTestsFileSystemErrorsTestsUaFileStreamTestsFileSystemClientOptionsTestsTemporaryFileTransferClientTestsFileSystemClientPathResolutionTestsFileSystemClientEnumerationTestsFileSystemClientMetadataTestsRefreshAsyncFileSystemClientCrudTestsBuild verification:
dotnet buildonOpc.Ua.Client.csprojandOpc.Ua.Client.Tests.csproj— 0 errors, 0 warnings across all 6 TFMs (net472,net48,netstandard2.1,net8.0,net9.0,net10.0).dotnet format style + whitespace + analyzers --severity info --verify-no-changes— exit 0 on both projects.Documentation
Docs/FileSystemClient.md(~12 KB): overview, getting started, path syntax, error mapping table, recursive-delete semantics, temporary-file-transfer flow.Tools/Opc.Ua.SourceGeneration/readme.md"ObjectType client proxies" section.Repository agent
Bonus: this PR also adds
.github/agents/dotnet-format.agent.md— a repo agent that scopesdotnet format(whitespace + style + analyzers) to user-specified files and chases remaining CA/IDE/RCS warnings to a 0-warning build. Includes a per-warning fix cookbook and theDisposeAsyncpattern documentation.Related Issues
Types of changes
Checklist
Further comments
The implementation is purely additive — no existing files are modified except for a one-line cross-reference in
Tools/Opc.Ua.SourceGeneration/readme.md. Everything else lives in new files underLibraries/Opc.Ua.Client/FileSystem/,Tests/Opc.Ua.Client.Tests/FileSystem/,Docs/, and.github/agents/.Layered architecture choice:
FileSystemClientdeliberately stays out of theOpc.Uanamespace (lives inOpc.Ua.Client.FileSystem) so it does not mix the System.IO-style abstractions with the raw OPC UA service surface.TemporaryFileTransferClientis a separate sibling type because its lifecycle (server-allocated transient file + commit/rollback) does not fit theSystem.IOmodel.